Skip to content

A writer that gives back the file it was given - #45

Merged
samuelduchesne merged 5 commits into
mainfrom
006-lossless-round-trip
Sep 6, 2026
Merged

A writer that gives back the file it was given#45
samuelduchesne merged 5 commits into
mainfrom
006-lossless-round-trip

Conversation

@samuelduchesne

Copy link
Copy Markdown
Contributor

Closes #12.

parseIdf(text, schema, { preserveFormatting: true }) retains feature 005's syntax layer and an index from each statement to the object it produced. writeIdf then walks the two together: every object nothing has changed is reproduced from the characters it was read from, and everything between the objects is copied.

Blocked on idfkit/idfkit-conformance#4. This branch pins conformance-2026.10 and governance-2026.14, and those tags are cut from that repository's main after it merges. CI here is red until they exist — that is "publish before you pin" working, not a fault in this branch.

Why the diff is small for what it does

The gaps between statements are emitted unconditionally and only the statements are decided. Every character is either inside a statement or in a gap, because the layer tiles the text, so the comments, the blank lines that group four hundred surfaces into rooms, the line endings whatever they are, and the presence or absence of a trailing newline are all copied without a decision. Three requirements fall out rather than being enforced: nothing is reordered, no header is added, and the version statement is not moved.

The read makes one pass. scanIdf and lex now expose their scan handlers and the preserving read composes them, so the option costs the layer's own work rather than a second read of the text. With the option off, nothing is built.

The touched record closes three paths that notified nobody

  • The rename. It rewrites every referencing field directly, bypassing the accessor on purpose, so none of those objects said they had changed. A writer trusting the listener emitted them from their original text, producing a file that loads and names a construction layer that no longer exists. The mark is made on both branches of the retarget loop; the second is reached only by a reference held inside an extensible repeat, so a mark on the first alone passes every test whose reference is a plain field.
  • In-place extensible mutation. get extensible() handed back the object's own array. It now returns an Array subclass whose mutators are heard, holding repeats whose fields are own accessors. No Proxy.
  • Creation, cloning and attachment, which leave an object with no index.

One spelling is still not heard — replacing a whole repeat by index — and it is documented and tested as the limit it is, with splice asserted beside it as the tracked alternative.

Measured, and one number moved because of it

bench/preserve.mjs reports three figures. One of them changed the design: reading a coordinate through an armed repeat cost 33x a plain read over the reference model's 33,012 vertices, which is exactly the price rejecting a Proxy was meant to avoid, reached by another road. Repeats are now armed only when the document carries a retained source — an editing document pays it, a geometry pipeline does not, and the non-preserving path is back to 1.75x.

It also reports a number the plan did not expect: a preserving read costs 1.54x a plain read, not the 1.25x SC-005 states. The two are different claims. The layer's own budget, that scanIdf costs no more than a quarter over a plain read, holds at 0.53x and is gated in bench/budget.mjs. A preserving read does both jobs, so its floor is the sum.

SC-012 was amended

The writer is 47.7 KB of dist and put the install 14,308 bytes over 1.75 MB. The budget moved to 1.875 MiB, in its own commit, with the reasoning in the gate's header: the capability is core, 1.8 MiB would have cleared the measurement by less than the feature cost, and this leaves 114 KB. The lever nobody pulled is recorded there too — 396 KB of the install is source maps, which serve debugging and nothing at runtime.

packages/idfkit/language.js claimed "98.3 KB free". That was three features stale; the real figure when this work started was 32.7 KB, and the plan was written against the wrong one.

`parseIdf(text, schema, { preserveFormatting: true })` retains the syntax layer
feature 005 built and an index from each statement to the object it produced.
`writeIdf` then walks the two together: every object nothing has changed is
reproduced from the characters it was read from, and everything between the
objects is copied.

The gaps are unconditional and only the statements are decided. Every character
is either inside a statement or in a gap, because the layer tiles the text, so
the comments, the blank lines, the line endings whatever they are and the
presence or absence of a trailing newline are all copied without a decision.
Three requirements fall out rather than being enforced: nothing is reordered, no
header is added, and the version statement is not moved.

The read makes ONE pass. `scanIdf` and `lex` now expose their scan handlers, and
the preserving read composes them, so the option costs the layer's own budget
rather than a second read of the text. With the option off nothing is built and
reading costs what it cost before.

THE TOUCHED RECORD closes three paths that notified nobody:

- A rename rewrites every referencing field directly, bypassing the accessor on
  purpose, so none of those objects said they had changed. A writer trusting the
  listener emitted them from their original text, producing a file that loads
  and names a construction layer that no longer exists. The mark is made on both
  branches of the retarget loop; the second one is reached only by a reference
  held inside an extensible repeat.

- `get extensible()` handed back the object's own array, so pushing a vertex
  reached the object without passing any accessor. It now returns an Array
  subclass whose mutators are heard, holding repeats whose fields are accessors.
  No `Proxy`: one would charge every vertex READ to catch a write. One spelling
  is still not heard, replacing a whole repeat by index, and it is documented and
  tested as the limit it is.

- Creation, cloning and attachment leave an object with no index, so it is
  touched from the moment it exists.

The object notation preserves on all-or-nothing terms, in both languages: the
retained text comes back only while nothing has been touched, added or removed,
and removal is decided from the count at read rather than from a scan of the
survivors, which cannot see one.

Asking to preserve and to reformat is refused rather than resolved. Asking to
preserve and for a different output form produces the form, because a form is a
different artifact the source was never going to express.

KNOWN, MEASURED, NOT FIXED: this puts the shared install 11.2 KB over its
1.75 MiB budget. The plan assumed 98.3 KB of headroom; main actually has 32.7 KB
and the writer is 47.7 KB of dist, over a third of it source maps and doc
comments. Reported rather than absorbed.
… a rename

Six examples under docs-snippets/how-to/preserve-formatting, compiled against
the published surface as every other example is: reading with preservation,
writing back unchanged, one edit showing as one object, asking a document
whether a write will preserve, the refusal and what is granted beside it, and
the object notation's all-or-nothing terms.
Measured, on the reference model's 33,012 vertices: reading a coordinate through
an armed repeat cost 33 times what reading it off a plain object cost. That is
precisely the price rejecting a `Proxy` was meant to avoid, arrived at by
another road.

A document read WITHOUT preservation has no touched record to maintain and
nothing a preserving write would consult, so it keeps the plain array and reads a
coordinate at 1.75x a pre-materialised one, which is the getter call and nothing
else. A document read WITH preservation is being edited, and an edit that
survives the save matters more there than the throughput of reading a vertex.

bench/preserve.mjs is the measurement, reported rather than gated: two of its
three numbers are success criteria and the third is the item the plan named as
the one to watch, and none of them has a defended threshold yet.

It reports one number the plan did not expect. Reading with preservation costs
1.54x a plain read, not the 1.25x SC-005 states. The two are different claims:
the layer's own budget, that scanIdf costs no more than a quarter over a plain
read, holds at 0.53x and is gated in bench/budget.mjs. A preserving read does
both jobs, so its floor is the sum, and the composed single pass saves the
character walk rather than the per-event work. Reported, not hidden.
SC-012 was amended from 1.75 MB to 1.875 MB. The gate's own header carries the
reasoning, as it carries the reasoning for the first amendment: the writer is
47.7 KB and is not optional weight, 1.8 MiB would have cleared the measurement by
less than the feature cost, and 1.875 MiB is the next figure in the same binary
series and leaves 114 KB.

It also records the lever nobody pulled. 396 KB of the install is source maps,
which serve debugging and nothing at runtime; dropping them from the published
files would free more than twice what this raise freed, and it is a packaging
decision rather than one feature's to make. Written down so the next time this
figure is under pressure, raising it again is a choice against a known
alternative rather than the only idea in the room.

packages/idfkit/language.js said "98.3 KB free". That figure was taken when the
language service landed and was three features stale; feature 006 was planned
against it and found 32.7 KB. It now says what the gate says.
@samuelduchesne
samuelduchesne requested review from a team as code owners September 6, 2026 16:02
@samuelduchesne
samuelduchesne merged commit 554f3ef into main Sep 6, 2026
39 of 43 checks passed
@samuelduchesne
samuelduchesne deleted the 006-lossless-round-trip branch September 6, 2026 17:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Port formatting-preserving round-trip to TypeScript

1 participant